跳到主要内容

Redis 实战:Web 应用

配置环境

<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>3.3.0</version>
</dependency>

<!-- json -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.2</version>
</dependency>

有两种常见的方法可以将登录信息存储在 cookie 里面:

签名(signed) cookie:通常会存储用户名,可能还会有其他网站觉得游泳的信息,例如:最后一次成功登录时间、用户 id 等。还会有签名,用服务器验证 cookie 中的信息是否被修改。

令牌(token) cookie:存储遗传随机字节作为令牌,服务器根据令牌在数据库中查找令牌的拥有着。随着时间的推移,旧令牌会被新令牌取代。

签名 cookie 和令牌 cookie 的优点与缺点

检查 Token

首先,我们将使用一个散列来存储登录 cookie 令牌与已登录用户之间的映射。要检查一个用户是否已经登录,需要根据给定的令牌来查找与之对应的用户,并在用户已经登录的情况下,返回该用户的 ID

/**
* 检查用户是否登陆
* @param conn Redis 连接
* @param token token
* @return 用户 id
*/
public String checkToken(Jedis conn, String token) {
return conn.hget("login:", token);
}

更新 Token

对令牌进行检查并不困难,因为大部分复杂的工作都是在更新令牌时完成的:用户每次浏览页面的时候,程序都会对用户存储在登录散列里面的信息进行更新,并将用户的令牌和当前时间戳添加到记录最近登录用户的有序集合里面

如果用户正在浏览的是一个商品页面,那么程序还会将这个商品添加到记录这个用户最近浏览过的商品的有序集合里面,并在被记录商品的数量超过 25 个时,对这个有序集合进行修剪。

/**
* 更新 Token 里面的信息
*
* @param conn Redis 连接
* @param token token
* @param user 当前登陆的用户的名称
* @param item 正在预览的商品
*/
public void updateToken(Jedis conn, String token, String user, String item) {
long timestamp = System.currentTimeMillis() / 1000; // 获取当前时间戳
// 维持令牌与已登录用户之间的映射
conn.hset("login:", token, user);
// 记录令牌最后一次出现的时间,注意这里使用的是有序集合(记录的是全部 Token 的登陆时间)
conn.zadd("recent:", timestamp, token);

if (item != null) {
// 记录用户浏览过的商品
conn.zadd("viewed:" + token, timestamp, item);
// 移除旧的记录,只保留用户最近浏览过的 25 个商品
// 这里下标可以为负:-1 表示最后一个成员,-2 表示倒数第二个成员,以此类推(注意:排序是从小到大的)
// 例如:conn.zremrangeByRank("viewed:" + token, 0, -26); 就是从大到小取 0 ~ 26 的元素
conn.zremrangeByRank("viewed:" + token, 0, -26);
}
}

清理会话数据

因为存储会话数据所需的内存会随着时间的推移而不断增加,所以我们需要定期清理旧的会话数据。为了限制会话数据的数量,我们决定只保存最新的 1000 万个会话。

清理旧会话的程序由一个循环构成,这个循环每次执行的时候,都会检查存储最近登录令牌的有序集合的大小,如果有序集合的大小超过了限制,那么程序就会从有序集合里面移除最多 100 个最旧的令牌,并从记录用户登录信息的散列里面,移除被删除令牌对应的用户的信息,并对存储了这些用户最近浏览商品记录的有序集合进行清理。

与此相反,如果令牌的数量未超过限制,那么程序会先休眠 1 秒,之后再重新进行检查。

创建一个守护线程,专门用来清理 Tokens

/**
* 清理会话的守护线程
*/
public static class CleanSessionsThread extends Thread {
private final Jedis conn;
private final int limit;
private boolean quit;

public CleanSessionsThread(int limit) {
this.conn = new Jedis("10.1.1.161", 6379);
this.conn.select(14);
this.limit = limit;
}

public void quit() {
quit = true;
}

@Override
public void run() {
while (!quit) {
// 找出当前令牌的数量
long size = conn.zcard("recent:");

if (size <= limit) {
try {
// 每次休眠一秒
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt(); // 如果休眠失败则中断线程
}
// 如果当前令牌数量并未超过限制,休眠后再进行下一次循环
continue;
}

// 能执行到这里说明令牌数量超出了,这里计算要移除的终点下标
long endIndex = Math.min(size - limit, 100);
Set<String> tokenSet = conn.zrange("recent:", 0, endIndex - 1);

// 这里传入的 new String[0] 单纯就是用来做泛型的类型转换的(看源码注释旧知道了)
String[] tokens = tokenSet.toArray(new String[0]);
ArrayList<String> sessionKeys = new ArrayList<>();
for (String token : tokens) {
sessionKeys.add("viewed:" + token);
}
// 批量删除 key value
conn.del(sessionKeys.toArray(new String[0]));
conn.hdel("login:", tokens);
conn.zrem("recent:", tokens);
}
}
}

测试上面的方法

    public void testLoginCookies(Jedis conn)
throws InterruptedException {
System.out.println("\n----- testLoginCookies -----");
String token = UUID.randomUUID().toString();

updateToken(conn, token, "username", "itemX");
System.out.println("We just logged-in/updated token: " + token);
System.out.println("For user: 'username'");
System.out.println();

System.out.println("What username do we get when we look-up that token?");
String r = checkToken(conn, token);
System.out.println(r);
System.out.println();
assert r != null;

System.out.println("Let's drop the maximum number of cookies to 0 to clean them out");
System.out.println("We will start a thread to do the cleaning, while we stop it later");

CleanSessionsThread thread = new CleanSessionsThread(0);
// 设置 thread 为守护线程,必须在 start 前完成设置。
thread.setDaemon(true); // 守护线程
thread.start();
Thread.sleep(1000);
thread.quit();
Thread.sleep(2000);
if (thread.isAlive()) {
throw new RuntimeException("The clean sessions thread is still alive?!?");
}

long s = conn.hlen("login:");
System.out.println("The current number of sessions still available is: " + s);
assert s == 0;
}

购物车功能

使用 cookie 实现购物车一也就是将整个购物车都存储到 cookie 里面的做法非常常见,这种做法的一大优点是无须对数据库进行写入就可以实现购物车功能,而缺点则是程序需要重新解析和验证 cookie,确保 cookie 的格式正确,并且包含的商品都是真正可购买的商品。

cookie 购物车还有一个缺点:因为浏览器每次发送请求都会连 cookie 一起发送,所以如果购物车 cookie 的体积比较大,那么请求发送和处理的速度可能会有所降低。

因为我们在前面已经使用 Redis 实现了会话 cookie 和记录用户最近浏览过的商品这两个特性,所以我们决定将购物车的信息也存储到 Redis 里面,并且使用与用户会话 cookie 相同的 cookie ID 来引用购物车。

购物车的定义非常简单:每个用户的购物车都是一个散列,这个散列存储了 商品 ID 与商品订购数量之间的映射。对商品数量进行验证的工作由 Web 应用程序负责,我们要做的则是在商品的订购数量出现变化时,对购物车进行更新:

1、如果用户订购某件商品的数量大于 0,那么程序会将这件商品的 ID 以及用户订购该商品的数量添加到散列里面,如果用户购买的商品已经存在于散列里面,那么新的订购数量会覆盖已有的订购数量;

2、相反地,如果用户订购某件商品的数量不大于 0,那么程序将从散列里面移除该条目。

如下代码:

/**
* 把当前商品加入购物车
*
* @param conn Redis 连接
* @param session SessionId
* @param item 商品
* @param count 数量
*/
public void addToCart(Jedis conn, String session, String item, int count) {
if (count <= 0) {
conn.hdel("cart:" + session, item);
} else {
conn.hset("cart:" + session, item, String.valueOf(count));
}
}

接着,我们需要对之前的会话清理函数进行更新,让它在清理旧会话的同时,将旧会话对应用户的购物车也一并删除

/**
* 清理会话的守护线程(和上面的区别就是会清除购物车)
*/
public static class CleanFullSessionsThread extends Thread {
// ...
@Override
public void run() {
while (!quit) {
// ...
String[] sessions = sessionSet.toArray(new String[0]);
ArrayList<String> sessionKeys = new ArrayList<>();
for (String sess : sessions) {
sessionKeys.add("viewed:" + sess);
sessionKeys.add("cart:" + sess); // 唯一区别就是这里,清空购物车
}
conn.del(sessionKeys.toArray(new String[0]));
conn.hdel("login:", sessions);
conn.zrem("recent:", sessions);
}
}
}

我们现在将会话和购物车都存储到了 Redis 里面,这种做法除了可以减少请求的体积之外,还使得我们可以根据用户浏览过的商品、用户放入购物车的商品以及用户最终购买的商品进行统计计算,并构建起很多大型网络零售商都在提供的 “在查看过这件商品的用户当中,有 X% 的用户最终购买了这件商品”、“购买了这件商品的用户也购买了某某其他商品” 等功能。这些功能可以帮助用户查找其他相关的商品,并最终提升网站的销售业绩。

通过将会话 cookie 和购物车 cookie 存储在 Redis 里面,我们得到了进行数据分析所需的两个重要的数据来源。

测试购物车功能

基本同上一致:

    public void testShopppingCartCookies(Jedis conn)
throws InterruptedException {
System.out.println("\n----- testShopppingCartCookies -----");
String token = UUID.randomUUID().toString();

System.out.println("We'll refresh our session...");
updateToken(conn, token, "username", "itemX");
System.out.println("And add an item to the shopping cart");
addToCart(conn, token, "itemY", 3);
Map<String, String> r = conn.hgetAll("cart:" + token);
System.out.println("Our shopping cart currently has:");
for (Map.Entry<String, String> entry : r.entrySet()) {
System.out.println(" " + entry.getKey() + ": " + entry.getValue());
}
System.out.println();

assert r.size() >= 1;

System.out.println("Let's clean out our sessions and carts");
CleanFullSessionsThread thread = new CleanFullSessionsThread(0);
thread.setDaemon(true); // 守护线程
thread.start();
Thread.sleep(1000);
thread.quit();
Thread.sleep(2000);
if (thread.isAlive()) {
throw new RuntimeException("The clean sessions thread is still alive?!?");
}

r = conn.hgetAll("cart:" + token);
System.out.println("Our shopping cart now contains:");
for (Map.Entry<String, String> entry : r.entrySet()) {
System.out.println(" " + entry.getKey() + ": " + entry.getValue());
}
assert r.size() == 0;
}

网页缓存

在动态生成网页的时候,通常会使用模板语言(templating language)来简化网页的生成操作。

需要手写每个页面的日子已经一去不复返一现在的 Web 页面通常由包含首部、尾部、侧栏菜单、工具条、内容域的模板生成,有时候模板还用于生成 JavaScript。

尽管这个网站也能够动态地生成内容,但这个网站上的多数页面实际,上并不会经常发生大的变化:虽然会向分类中添加新商品、移除旧商品、有时有特价促销、有时甚至还有 “热卖商品” 页面,但是在一般情况下,网站只有账号设置、以往订单、购物车(结账信息)以及其他少数几个页面才包含需要每次载人都要动态生成的内容。

通过对浏览数据进行分析,这个网站发现自己所处理的 95% 的 Web 页面每天最多只会改变一次,这些页面的内容实际上并不需要动态地生成,而我们的工作就是想办法不再生成这些页面。

减少网站在动态生成内容上面所花的时间,可以降低网站处理相同负载所需的服务器数量,并让网站的速度变得更快。

我们将创建一个这样的层来调用 Redis 缓存函数:对于一个不能被缓存的请求,函数将直接生成并返回页面;而对于可以被缓存的请求,函数首先会尝试从缓存里面取出并返回被缓存的页面,如果缓存页面不存在,那么函数会生成页面并将其缓存在 Redis 里面 5 分钟,最后再将页面返回给函数调用者。

    /**
* 创建一个任务回调接口,用来返回生成的页面
*/
public interface Callback {
String call(String request);
}

/**
* 缓存网页
*
* @param conn Redis 连接
* @param request 请求
* @param callback 创建页面的回调函数
* @return 生成的页面
*/
public String cacheRequest(Jedis conn, String request, Callback callback) {
// 对于不能缓存的请求,直接调用回调函数
if (!canCache(conn, request)) {
return callback != null ? callback.call(request) : null;
}
// 缓存页的 key
String pageKey = "cache:" + hashRequest(request);
// 取得缓存页的内容
String content = conn.get(pageKey);
// 如果内容为空则还是走回调函数,但是会把回调函数的结果存进 Redis 里面
if (content == null && callback != null) {
content = callback.call(request);
// 设置一个 5 分钟的过期时间
conn.setex(pageKey, 300, content);
}
// 返回内容
return content;
}

/**
* 判断是否能缓存请求
*
* @param conn Redis 连接
* @param request 请求
* @return 是否能缓存请求
*/
public boolean canCache(Jedis conn, String request) {
try {
URL url = new URL(request);
HashMap<String, String> params = new HashMap<>();
if (url.getQuery() != null) {
// 这里取得参数 传入的请求为:"http://test.com/?item=itemX&_=1234536"
for (String param : url.getQuery().split("&")) {
String[] pair = param.split("=", 2);
params.put(pair[0], pair.length == 2 ? pair[1] : null);
}
}
// 取得商品 ID
String itemId = extractItemId(params);
// 如果携带了动态参数说明这个请求无法走缓存
if (itemId == null || isDynamic(params)) {
return false;
}
// 返回集合 key 中某一个元素在该集合内的排名,按照 score 值从 “小到大” 排列
// 这里返回 itemId 在 viewed 集合里面的排名
Long rank = conn.zrank("viewed:", itemId);
// 如果当前商品在 1000 开外就不用缓存了
return rank != null && rank < 10000;
} catch (MalformedURLException mue) {
return false;
}
}

/**
* 判断是否携带动态参数
*
* @param params 商品的参数
* @return 判断是否有 “_” 这个参数
*/
public boolean isDynamic(Map<String, String> params) {
return params.containsKey("_");
}

/**
* 取得商品 ID
* @param params 商品的参数
* @return 商品 ID
*/
public String extractItemId(Map<String, String> params) {
return params.get("item");
}

/**
* 把请求转成 hash 方便比对
* @param request 请求
* @return 转换的 hashCode
*/
public String hashRequest(String request) {
return String.valueOf(request.hashCode());
}

测试网页缓存

public void testCacheRequest(Jedis conn) {
System.out.println("\n----- testCacheRequest -----");
String token = UUID.randomUUID().toString();

Callback callback = new Callback() {
public String call(String request) {
return "content for " + request;
}
};

updateToken(conn, token, "username", "itemX");
String url = "http://test.com/?item=itemX";
System.out.println("将缓存一个简单的请求 " + url);
String result = cacheRequest(conn, url, callback);
System.out.println("得到了初始内容:\n" + result);
System.out.println();

assert result != null;

System.out.println("为了测试我们已经缓存了请求,我们将传递一个空的回调 ");
String result2 = cacheRequest(conn, url, null);
System.out.println("我们最终得到了相同的回应!\n" + result2);

assert result.equals(result2);
assert !canCache(conn, "http://test.com/");
// 携带了动态参数的无法走缓存
assert !canCache(conn, "http://test.com/?item=itemX&_=1234536");
}

数据行缓存

为了应对促销活动带来的大量负载,我们需要对数据行进行缓存,具体的做法是:编写一个持续运行的守护线程函数,让这个函数将指定的数据行缓存到 Redis 里面,并不定期地对这些缓存进行更新。

缓存函数会将数据行编码(encode)为 JSON 字典并存储在 Redis 的字符串里面,其中,数据列(column)的名字会被映射为 JSON 字典的键,而数据行的值则会被映射为 JSON 字典的值

程序使用了两个有序集合来记录应该在何时对缓存进行更新:

第一个有序集合为 调度(schedule)有序集合,它的成员为数据行的行 ID,而分值则是一个时间戳,这个时间戳记录了 应该在何时将指定的数据行缓存到 Redis 里面。

第二个有序集合为 延时(delay)有序集合,它的成员也是数据行的行 ID,而分值则记录了指定数据行的缓存需要 每隔多少秒更新一次。

为了让缓存函数定期地缓存数据行,程序首先需要将行 ID 和给定的延迟值添加到延迟有序集合里面,然后再将行 ID 和当前时间的时间戳添加到调度有序集合里面。

实际执行缓存操作的函数需要用到数据行的延迟值,如果某个数据行的延迟值不存在,那么程序将取消对这个数据行的调度。如果我们想要移除某个数据行已有的缓存,并且让缓存函数不再缓存那个数据行,那么只需要把那个数据行的延迟值设置为小于或等于 0 就可以了(等于或小于零表示这个值不能缓存,需要每次都读取到最新的数据)。

/**
* 负责调度缓存和终止缓存的函数
*
* @param conn Redis 连接
* @param rowId 需要缓存的数据行
* @param delay 延迟值
*/
public void scheduleRowCache(Jedis conn, String rowId, int delay) {
// 先设置数据行的延迟值
conn.zadd("delay:", delay, rowId);
// 立即对需要缓存的数据行进行调度
conn.zadd("schedule:", System.currentTimeMillis() / 1000, rowId);
}

现在我们已经完成了调度部分,那么接下来该如何对数据行进行缓存呢?

负责缓存数据行的函数会尝试读取调度有序集合的第一个元素以及该元素的分值,如果调度有序集合没有包含任何元素,或者分值存储的时间戳所指定的时间尚未来临,那么函数会先休眠 50 毫秒,然后再重新进行检查。

当缓存函数发现一个需要立即进行更新的数据行时,缓存函数会检查这个数据行的延迟值:

1、如果数据行的延迟值小于或者等于 0,那么缓存函数会从延迟有序集合和调度有序集合里面移除这个数据行的ID,并从缓存里面删除这个数据行已有的缓存,然后再重新进行检查;

2、对于延迟值大于 0 的数据行来说,缓存函数会从数据库里面取出这些行,将它们编码为 JSON 格式并存储到 Redis 里面,然后更新这些行的调度时间。

首先创建一个测试用的实体类:

/**
* 创建的一个库存实体类(数据行)
*/
public static class Inventory {
private String id;
private String data;
private long time;

private Inventory(String id) {
this.id = id;
this.data = "data to cache...";
this.time = System.currentTimeMillis() / 1000;
}

// 这个方法模拟从数据库取得数据(实体)
public static Inventory get(String id) {
return new Inventory(id);
}
}

然后创建 添加缓存数据行的守护线程

/**
* 添加缓存数据行的守护线程
*/
public static class CacheRowsThread extends Thread {
private final Jedis conn;
private boolean quit;

public CacheRowsThread() {
this.conn = new Jedis("localhost");
this.conn.select(14);
}

public void quit() {
quit = true;
}

@Override
public void run() {
ObjectMapper mapper = new ObjectMapper();
while (!quit) {
Set<Tuple> range = conn.zrangeWithScores("schedule:", 0, 0);
// 尝试从当前队列里面取需要被缓存的数据行以及该行的调度时间戳(返回的这个 Tuple 会包含零个或一个元组的列表)
Tuple next = !range.isEmpty() ? range.iterator().next() : null;
long now = System.currentTimeMillis() / 1000;

// 暂时没有行需要被缓存,休眠 50 毫秒后重试
if (next == null || next.getScore() > now) {
try {
// 休眠 50毫秒后重试
TimeUnit.MILLISECONDS.sleep(50);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
}
continue;
}

String rowId = next.getElement();
// 提前获取下一次调度的延迟时间
double delay = conn.zscore("delay:", rowId);
// 如果延迟时间小于等于 0 则将其从缓存中移除
if (delay <= 0) {
conn.zrem("delay:", rowId);
conn.zrem("schedule:", rowId);
conn.del("inv:" + rowId);
continue;
}

// 这里模拟读取数据行
Inventory row = Inventory.get(rowId);
// 更新调度时间
conn.zadd("schedule:", now + delay, rowId);
try {
// 设置缓存值(把当前数据行实体对象转成 JSON 数据存到 Redis 里面)
conn.set("inv:" + rowId, mapper.writeValueAsString(row));
} catch (JsonProcessingException e) {
e.printStackTrace();
}
}
}
}

测试数据行缓存功能

    public void testCacheRows(Jedis conn)
throws InterruptedException {
System.out.println("\n----- testCacheRows -----");
System.out.println("First, let's schedule caching of itemX every 5 seconds");
scheduleRowCache(conn, "itemX", 5);
System.out.println("Our schedule looks like:");
Set<Tuple> s = conn.zrangeWithScores("schedule:", 0, -1);
for (Tuple tuple : s) {
System.out.println(" " + tuple.getElement() + ", " + tuple.getScore());
}
assert s.size() != 0;

System.out.println("We'll start a caching thread that will cache the data...");

CacheRowsThread thread = new CacheRowsThread();
thread.setDaemon(true);
thread.start();

Thread.sleep(1000);
System.out.println("Our cached data looks like:");
String r = conn.get("inv:itemX");
System.out.println(r);
assert r != null;
System.out.println();

System.out.println("We'll check again in 5 seconds...");
Thread.sleep(5000);
System.out.println("Notice that the data has changed...");
String r2 = conn.get("inv:itemX");
System.out.println(r2);
System.out.println();
assert r2 != null;
assert !r.equals(r2);

System.out.println("Let's force un-caching");
scheduleRowCache(conn, "itemX", -1);
Thread.sleep(1000);
r = conn.get("inv:itemX");
System.out.println("The cache was cleared? " + (r == null));
assert r == null;

thread.quit();
Thread.sleep(2000);
if (thread.isAlive()) {
throw new RuntimeException("The database caching thread is still alive?!?");
}
}

网页分析

网站可以从用户的访问、交互和购买行为中收集到有价值的信息。例如,如果我们 只想关注那些浏览量最高的页面,那么我们可以尝试修改页面的格局、配色甚至是页面上展示的其他链接。

前面介绍了如何记录用户浏览过的商品或者用户添加到购物车中的商品,通过缓存 Web 页面来减少页面载人时间并提升页面的响应速度。不过遗憾的是,我们对 Fake Web Retailer(这个 Web 应用的名称)采取的缓存措施做得过了火: Fake Web Retailer 总共包含 100 000 件商品,而冒然地缓存所有商品页面将耗尽整个网站的全部内存!

经过一番调研之后, 我们决定只对其中 10000 件商品的页面进行缓存。

前面的 Cookie 那节介绍过,每个用户都有一个相应的记录用户浏览商品历史的有序集合,尽管使用这些有序集合可以计算出用户最经常浏览的商品,但进行这种计算却需要耗费大量的时间。

为了解决这个问题,可以在上面的 updateToken 函数里面添加一行代码

/**
* 更新 Token 里面的信息
*
* @param conn Redis 连接
* @param token token
* @param user 当前登陆的用户的名称
* @param item 正在预览的商品
*/
public void updateToken(Jedis conn, String token, String user, String item) {
long timestamp = System.currentTimeMillis() / 1000;
conn.hset("login:", token, user);
conn.zadd("recent:", timestamp, token);
if (item != null) {
conn.zadd("viewed:" + token, timestamp, item);
conn.zremrangeByRank("viewed:" + token, 0, -26);

// 这行代码是新加的
// zincrby 可以为集合 key 中成员 member 的 score 值加上增量,并调整位置,参数 score 可以是负数,表示递减
// 注意:这里的 "viewed:" 和上面的 "viewed:" + token 不是一个集合,别看错了
// 这个 "viewed:" 是商品集合
// "viewed:" + token 是用户阅览集合(如果这个 item 不存在集合里面,它会自动创建)
conn.zincrby("viewed:", -1, item);
}
}

新添加的代码记录了所有商品的浏览次数,并根据浏览次数对商品进行了排序,被浏览得最多的商品将被放到有序集合的索引 0 位置上 (注意上面的 -1 操作,每预览一次,这个 item 的排位就上升一位),并且具有整个有序集合最少的分值。

随着时间的流逝,商品的浏览次数会呈现两极分化的状态,一些商品的浏览次数会越来越多,而另一些商品的浏览次数则会越来越少。

除了缓存最常被浏览的商品之外,程序还需要发现那些变得越来越流行的新商品,并在合适的时候缓存它们。

为了让商品浏览次数排行榜能够保持最新,需要定期修剪有序集合的长度并调整已有元素的分值,从而使得新流行的商品也可以在排行榜里面占据一席之地。

之前的2.1节中已经介绍过从有序集合里面移除元素的方法,而调整元素分值的动作则可以通过 ZINTERSTORE 命令来完成。ZINTERSTORE 命令可以组合起一个或多个有序集合,并将有序集合包含的每个分值都乘以一个给定的数值(用户可以为每个有序集合分别指定不同的相乘数值)。

每隔 5 分钟(所以创建一个定时的守护进程),这个函数就会删除所有排名在 20000 名之后的商品,并将删除之后剩余的所有商品的浏览次数减半。

/**
* 重新调整商品列表的守护线程
*/
public static class RescaleViewed extends Thread {
private final Jedis conn;
private boolean quit;

public RescaleViewed() {
this.conn = new Jedis("10.1.1.161", 6379);
this.conn.select(14);
}

public void quit() {
quit = true;
}

@Override
public void run() {
while (!quit) {
// 删除所有排名在 20 000 名之后的商品
conn.zremrangeByRank("viewed:", 0, -20001);
ZParams params = new ZParams().weights(0.5);
// 将浏览次数降低为原来的一半(这里主要利用 ZParams 的乘法因子 weights,每次操作并集时会让 score 乘上这个因子)
conn.zinterstore("viewed:", params);
try {
TimeUnit.SECONDS.sleep(300); // 5 分钟后再次操作
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
}

完整的代码

Gist 地址